/*
 * Copyright 2008 Paul Burlov
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package de.burlov.amazon.s3.dirsync;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.net.URI;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.io.input.CountingInputStream;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bouncycastle.crypto.BlockCipher;
import org.bouncycastle.crypto.CipherParameters;
import org.bouncycastle.crypto.engines.SerpentEngine;
import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
import org.bouncycastle.crypto.params.KeyParameter;
import org.jets3t.service.S3Service;
import org.jets3t.service.S3ServiceException;
import org.jets3t.service.impl.rest.httpclient.RestS3Service;
import org.jets3t.service.model.S3Bucket;
import org.jets3t.service.model.S3Object;
import org.jets3t.service.security.AWSCredentials;

import de.burlov.amazon.s3.RegExpUtil;
import de.burlov.amazon.s3.S3Utils;
import de.burlov.amazon.s3.dirsync.datamodel.v1.FileInfo;
import de.burlov.amazon.s3.dirsync.datamodel.v1.Folder;
import de.burlov.amazon.s3.dirsync.datamodel.v1.MainIndex;
import de.burlov.bouncycastle.io.CryptInputStream;
import de.burlov.bouncycastle.io.CryptOutputStream;

/**
 * Primaere s3dirsync Klasse mit High Level API
 * 
 * @author paul
 * 
 */
public class DirSync
{
	static final private byte[] salt = new byte[] { (byte) 89, (byte) 43, (byte) 94, (byte) 02, (byte) 20,
			(byte) 45, (byte) 123, (byte) 1, (byte) 0, (byte) 204 };
	static final private int ITERATION_COUNT = 30000;
	static final private String SYS_DATA_PREFIX = "system";
	static final private String FILES_PREFIX = "data";
	static final private String MAIN_INDEX_KEY = "main-index";
	static final private String DELIMITER = "/";
	// static final private String USER_METADATA_PREFIX = "x-amz-meta-";
	// static final private String METADATA_FILE = USER_METADATA_PREFIX +
	// "file";
	// static final private String METADATA_FOLDER = USER_METADATA_PREFIX +
	// "folder";
	
	private String bucket;
	private String location;
	private S3Service s3Service;
	private byte[] pbeKey;
	private BlockCipher cipher;
	private MainIndex mainIndex;
	private Log log = LogFactory.getLog(DirSync.class);
	private Map<String, Folder> folderCache = new HashMap<String, Folder>();
	private int deletedFiles;
	private int transferredFiles;
	private long transferredData;
	private String accessKey;
	private String secretKey;
	private MessageDigest shaDigest;
	private MessageDigest md5Digest;
	private List<Pattern> excludePatterns = new LinkedList<Pattern>();
	private List<Pattern> includePatterns = new LinkedList<Pattern>();
	
	public DirSync(String accessKey, String secretKey, String bucket, String location, char[] encPassword)
		throws DirSyncException
	{
		super();
		try
		{
			shaDigest = MessageDigest.getInstance("SHA-1");
			md5Digest = MessageDigest.getInstance("MD5");
		}
		catch (NoSuchAlgorithmException e)
		{
			throw new DirSyncException(e.getMessage());
		}
		this.accessKey = accessKey;
		this.secretKey = secretKey;
		if (encPassword == null)
		{
			throw new IllegalArgumentException("encryption password is null");
		}
		this.bucket = bucket;
		this.location = location;
		if (StringUtils.equalsIgnoreCase(location, S3Bucket.LOCATION_EUROPE))
		{
			this.location = S3Bucket.LOCATION_EUROPE;
		}
		else
		{
			this.location = null;
		}
		if (StringUtils.isBlank(this.bucket))
		{
			this.bucket = accessKey + ".dirsync";
		}
		cipher = new SerpentEngine();
		pbeKey = generatePbeKey(encPassword);
		try
		{
			s3Service = new RestS3Service(new AWSCredentials(accessKey, secretKey));
		}
		catch (S3ServiceException e1)
		{
			throw new DirSyncException("Connecting to S3 service failed", e1);
		}
	}
	
	/**
	 * 
	 * @param autocreate
	 *        'true' wenn fehlende Index/bucket automatisch erstellt werden sollen
	 * @throws DirSyncException
	 */
	private void connect(boolean autocreate) throws DirSyncException
	{
		if (mainIndex != null)
		{
			return;
		}
		boolean bucketExists = false;
		try
		{
			bucketExists = s3Service.isBucketAccessible(bucket);
		}
		catch (S3ServiceException e1)
		{
			throw new DirSyncException("Internal error: " + e1.getMessage());
		}
		if (!bucketExists)
		{
			if (autocreate)
			{
				/*
				 * In 'up' Modus benoetigte Bucket erstellen falls er noch nicht vorhanden ist
				 */
				try
				{
					s3Service.createBucket(bucket, location);
				}
				catch (S3ServiceException e2)
				{
					throw new DirSyncException("Creating bucket '" + bucket + "' failed: "
						+ e2.getLocalizedMessage());
				}
			}
			else
			{
				throw new DirSyncException("Bucket not found: " + bucket);
			}
		}
		try
		{
			mainIndex = (MainIndex) downloadObject(getMainIndexKey(), pbeKey);
		}
		catch (IOException e)
		{
			/*
			 * Lesen des Indexes fehlgeschlagen, falsches Passwort?
			 */
			throw new DirSyncException("Reading main index failed. Are password and S3 login data valid?", e);
		}
		if (mainIndex == null)
		{
			if (autocreate)
			{
				/*
				 * Noch keine Daten auf dem Server
				 */
				mainIndex = new MainIndex();
				/*
				 * Schluessel fuer Datenverschluesselung generieren
				 */
				SecureRandom srnd = new SecureRandom();
				srnd.setSeed(pbeKey);
				byte[] dataKey = new byte[32];
				srnd.nextBytes(dataKey);
				mainIndex.setEncryptionKey(dataKey);
			}
			else
			{
				/*
				 * Kein Index gefunden, also keine Daten zum Runterladen
				 */
				throw new DirSyncException("No data found");
			}
		}
	}
	
	/**
	 * Sensible Daten in Hauptspeicher explizit ueberschreiben
	 */
	public void close()
	{
		if (pbeKey != null)
		{
			Arrays.fill(pbeKey, (byte) 0);
		}
		if (mainIndex != null)
		{
			Arrays.fill(mainIndex.getEncryptionKey(), (byte) 0);
		}
		accessKey = null;
		secretKey = null;
	}
	
	/**
	 * Synchronisiert Daten im lokalen Verzeichnis und auf dem Server
	 * 
	 * @param baseDir
	 * @param folderName
	 * @param up
	 *        Synchronisationsrichtung. Wenn 'true' dann werden Daten von lokalen Verzeichnis auf
	 *        den Server geschrieben. Wenn 'false' Dann werden Daten von Server auf lokalen
	 *        Verzeichnis geschrieben
	 * @param snapShot
	 *        Wenn 'true' dann wird Synchronisierung in 'Abbild' Modus duchgefuehrt. D.h.
	 *        Zielverzeichnis wird auf den gleichen Stand wie Quelle gebracht: neue Dateien werde
	 *        geloescht, geloeschte wiederherstellt und modifiezierte upgedated. Wenn 'false' dann
	 *        werden neue Dateien hinzugefuegt und modifizierte upgedated aber auf keinen Fall
	 *        irgendetwas geloescht.
	 * @throws DirSyncException
	 */
	public void syncFolder(File baseDir, String folderName, boolean up, boolean snapShot) throws DirSyncException
	{
		connect(up);
		deletedFiles = 0;
		transferredFiles = 0;
		transferredData = 0;
		if (!baseDir.isDirectory())
		{
			throw new DirSyncException("Invalid directory: " + baseDir.getAbsolutePath());
		}
		Folder folder = getFolder(folderName);
		if (folder == null)
		{
			if (!up)
			{
				log.warn("No such folder " + folderName);
				//System.out.println("No such folder " + folderName);
				return;
			}
			folder = new Folder(folderName, Long.toHexString(mainIndex.getNextId()));
		}
		Collection<LocalFile> files;
		try
		{
			files = generateChangedFileList(baseDir, folder);
		}
		catch (Exception e)
		{
			throw new DirSyncException(e);
		}
		if (up)
		{
			syncUp(folder, files, snapShot);
		}
		else
		{
			syncDown(folder, baseDir, files, snapShot);
		}
		log.info("Transferred data: " + FileUtils.byteCountToDisplaySize(transferredData));
		log.info("Transferred files: " + transferredFiles);
		log.info("Deleted/Removed files: " + deletedFiles);
	}
	
	public void deleteFolder(String folderName) throws DirSyncException
	{
		connect(false);
		Folder folder = getFolder(folderName);
		if (folder == null)
		{
			return;
		}
		for (Map.Entry<String, FileInfo> info : folder.getIndexData().entrySet())
		{
			try
			{
				deleteRemoteFile(info.getValue().getStorageId());
			}
			catch (S3ServiceException e)
			{
				log.error("Unable to delete file " + info.getKey(), e);
			}
		}
		mainIndex.getFolders().remove(folderName);
		try
		{
			saveMainIndex();
		}
		catch (S3ServiceException e)
		{
			throw new DirSyncException("Unable to delete main index entry", e);
		}
		folderCache.remove(folderName);
		try
		{
			s3Service.deleteObject(bucket, getFolderKey(folder.getStorageId()));
		}
		catch (S3ServiceException e)
		{
			throw new DirSyncException("Unable to delete folder entry", e);
		}
		
	}
	
	/**
	 * Methode setzt Passwort auf neuen Wert. Beim naechsten connet() Aufruf muss schon neues
	 * Passwort mitgegeben werden.
	 * 
	 * @param newPassword
	 * @throws DirSyncException
	 */
	public void changePassword(char[] newPassword) throws DirSyncException
	{
		connect(false);
		pbeKey = generatePbeKey(newPassword);
		try
		{
			/*
			 * MainIndex mit neuen PBE-Key verschlusselt speichern. Daten-Schluessel bleibt
			 * unangetastet.
			 */
			saveMainIndex();
		}
		catch (S3ServiceException e)
		{
			throw new DirSyncException(e);
		}
	}
	
	private byte[] generatePbeKey(char[] password)
	{
		PKCS5S2ParametersGenerator pgen = new PKCS5S2ParametersGenerator();
		pgen.init(PKCS5S2ParametersGenerator.PKCS5PasswordToBytes(password), salt, ITERATION_COUNT);
		CipherParameters params = pgen.generateDerivedParameters(256);
		byte[] ret = ((KeyParameter) params).getKey();
		return ret;
	}
	
	private void saveFolder(Folder folder) throws DirSyncException
	{
		try
		{
			/*
			 * Neuen Folder-Objekt konstruieren und speichern
			 */
			Folder newFolder = new Folder(folder.getName(), Long.toHexString(mainIndex.getNextId()));
			newFolder.setLastModified(System.currentTimeMillis());
			newFolder.setIndexData(folder.getIndexData());
			uploadObject(SYS_DATA_PREFIX + "/" + newFolder.getStorageId(), getDataEncryptionKey(), newFolder);
			
			/*
			 * Main Index mit neuem Folder sichern
			 */
			mainIndex.getFolders().put(newFolder.getName(), newFolder.getStorageId());
			saveMainIndex();
			folderCache.put(newFolder.getName(), newFolder);
			
			/*
			 * Alten Folder-Objekt loeschen
			 */
			s3Service.deleteObject(bucket, getFolderKey(folder.getStorageId()));
			
		}
		catch (Exception e)
		{
			throw new DirSyncException("Save folder description failed", e);
		}
	}
	
	private void saveMainIndex() throws S3ServiceException
	{
		uploadObject(getMainIndexKey(), pbeKey, mainIndex);
	}
	
	/**
	 * Methode laedt lokale Dateien die als geaendert erkannt wurden auf den Server hoch
	 * 
	 * @param folder
	 * @param files
	 * @param snapShot
	 * @throws DirSyncException
	 */
	private void syncUp(Folder folder, Collection<LocalFile> files, boolean snapShot) throws DirSyncException
	{
		boolean dirty = false;
		long uploadedBytes = 0;
		for (LocalFile item : files)
		{
			File file = item.getLocalFile();
			if (file.exists())
			{
				/*
				 * Den Hashwert der lokalen Datei berechnen und versuchen zuerst eine bereits
				 * hochgeladene Dateie mit diesem Hash zu finden. Es kann je sein, dass lokale Datei
				 * nur umbenannt oder kopiert wurde, dann braucht man nicht die Daten noch mal
				 * hochzuladen.
				 */
				byte[] hash;
				try
				{
					hash = digestFile(file, shaDigest);
				}
				catch (IOException e1)
				{
					throw new DirSyncException("Unable to hash file: " + file.getAbsolutePath(), e1);
				}
				FileInfo info = folder.getFileInfo(hash);
				if (info != null)
				{
					/*
					 * Eine Datei existiert bereits mit so einem Hashwert. Keine Daten hochladen
					 * sondern nur bereits hochgeladene Datei zusaetzlich mit neuem Dateinamen
					 * verknuepfen
					 */
					folder.getIndexData().put(item.getRelativeName(), info);
					log.info("Link file with uploaded data: " + item.getRelativeName());
					info.setLastModified(file.lastModified());
					dirty = true;
				}
				else
				{
					/*
					 * Noch kein Datenobjekt auf dem Server mit so einem Hashwert. Daten muessen
					 * hochgeladen werden. Hochgeladene Objekte immer unter neuem Key speichern.
					 * Somit alte Daten auf jeden Fall erhalten bleiben bis Folder-Index hochgeladen
					 * wurde
					 */
					info = new FileInfo(file.lastModified(), file.length(),
						Long.toHexString(mainIndex.getNextId()));
					try
					{
						log.info("Uploading " + file.getAbsolutePath());
						/*
						 * Neue/geaenderte Datei uploaden
						 */
						uploadFile(file, getFileKey(info.getStorageId()));
						dirty = true;
						uploadedBytes += file.length();
						transferredData += file.length();
						transferredFiles++;
						info.setHash(hash);
					}
					catch (Exception e)
					{
						throw new DirSyncException("File upload '" + file.getAbsolutePath() + "' failed. "
							+ e.getLocalizedMessage());
					}
					folder.getIndexData().put(item.getRelativeName(), info);
					if (uploadedBytes > 10000000)
					{
						/*
						 * In bestimmten Intervalen die Indexinformationen auf dem Server
						 * aktualisieren, damit schon hochgeladenen Daten nicht verloren gehen
						 */
						saveFolder(folder);
						dirty = false;
						uploadedBytes = 0;
					}
				}
			}
			else if (snapShot)
			{
				/*
				 * Lokale Datei wurde geloescht, in 'snap shot' Modus auch auf dem Server loeschen
				 */
				if (folder.getIndexData().remove(item.getRelativeName()) != null)
				{
					log.info("Remove file " + item.getRelativeName());
					deletedFiles++;
					dirty = true;
					
				}
			}
		}
		/*
		 * Jetzt Folder-Index speichern und nicht mehr referenzierte Objekte loschen
		 */
		Set<String> idsToDelete = folder.syncFileHashIndex();
		if (dirty)
		{
			saveFolder(folder);
		}
		try
		{
			for (String id : idsToDelete)
			{
				deleteRemoteFile(id);
			}
		}
		catch (S3ServiceException e)
		{
			/*
			 * Fehler ist nicht schwerwiegend. Es werden hochstens verwaiste Objekte uebrigbleiben
			 * die mit 'cleanup' Befehle geloescht werden koennen
			 */
			log.error("Unable to delete remote file. " + e.getLocalizedMessage(), e);
		}
	}
	
	private void syncDown(Folder folder, File baseDir, Collection<LocalFile> files, boolean snapShot)
	{
		for (LocalFile item : files)
		{
			FileInfo info = folder.getIndexData().get(item.getRelativeName());
			File file = item.getLocalFile();
			if (info != null)
			{
				/*
				 * Vermissten oder geanderte Datei runterladen
				 */
				try
				{
					/*
					 * Zuerst Hashwert berechnen und vergleichen. Villeicht ist das Download gar
					 * nicht noetig
					 */
					byte[] hash = null;
					if (file.exists())
					{
						hash = digestFile(file, shaDigest);
					}
					if (info.getHash() == null || !Arrays.equals(info.getHash(), hash))
					{
						log.info("Downloading " + item.getRelativeName());
						downloadFile(file, getFileKey(info.getStorageId()));
						transferredFiles++;
						transferredData += file.length();
					}
					file.setLastModified(info.getLastModified());
				}
				catch (Exception e)
				{
					log.error("File download '" + item.getRelativeName() + "' failed. " + e.getLocalizedMessage());
				}
			}
			else if (snapShot)
			{
				/*
				 * neue lokale Dateien in 'snap shot' Modus loeschen
				 */
				log.info("Delete " + file.getAbsolutePath());
				if (!file.delete())
				{
					log.warn("Unable to delete file: " + file.getAbsolutePath());
				}
				else
				{
					deletedFiles++;
				}
				/*
				 * Wenn Verzeichnis keine Dateien mehr enthaelt, muss er auch geloescht werden
				 */
				deleteEmptyFolder(baseDir, file.getParentFile());
			}
		}
	}
	
	/**
	 * Loescht rekursiv leere Verzeichnisse
	 * 
	 * @param lowerDir
	 *        unterste Verzeichnis, der nicht geloscht werden darf
	 * @param dir
	 *        Verzeichnis zum loeschen
	 */
	private void deleteEmptyFolder(final File lowerDir, File dir)
	{
		if (dir != null && dir.exists() && dir.isDirectory() && !lowerDir.equals(dir))
		{
			File[] children = dir.listFiles();
			if (children == null || children.length == 0)
			{
				dir.delete();
				dir = dir.getParentFile();
				deleteEmptyFolder(lowerDir, dir);
			}
		}
	}
	
	private void uploadFile(File file, String key) throws IOException, S3ServiceException
	{
		file = prepareFileForUpload(file, key);
		/*
		 * MD5 der fuer hochladen preparierter Datei berechnen damit bei putten Amazon die
		 * Richtigkeit der uebertragenen Daten verifizieren kann
		 */
		byte[] digest = digestFile(file, md5Digest);
		try
		{
			S3Object obj = new S3Object(key);
			obj.setDataInputFile(file);
			obj.setMd5Hash(digest);
			obj.setContentLength(file.length());
			s3Service.putObject(bucket, obj);
		}
		finally
		{
			/*
			 * Datei anschliessend loeschen
			 */
			file.delete();
		}
	}
	
	/**
	 * Liefert Schluessel fuer primaere Datenverschluesselung
	 * 
	 * @return
	 */
	private byte[] getDataEncryptionKey()
	{
		assert mainIndex != null;
		assert mainIndex.getEncryptionKey() != null && mainIndex.getEncryptionKey().length >= 32;
		return mainIndex.getEncryptionKey();
	}
	
	/**
	 * Prepariert eine Datei zum Hochladen. Sie wird komprimiert und verschluesselt
	 * 
	 * @param source
	 * @return
	 * @throws IOException
	 */
	private File prepareFileForUpload(File source, String s3key) throws IOException
	{
		File tmp = File.createTempFile("dirsync", ".tmp");
		tmp.deleteOnExit();
		InputStream in = null;
		OutputStream out = null;
		try
		{
			in = new FileInputStream(source);
			out = new DeflaterOutputStream(new CryptOutputStream(new FileOutputStream(tmp), cipher,
				getDataEncryptionKey()));
			IOUtils.copy(in, out);
			in.close();
			out.close();
			return tmp;
		}
		finally
		{
			IOUtils.closeQuietly(in);
			IOUtils.closeQuietly(out);
		}
	}
	
	private void downloadFile(File target, String s3key) throws IOException, S3ServiceException
	{
		InputStream in = downloadData(s3key);
		if (in == null)
		{
			throw new IOException("No data found");
		}
		in = new InflaterInputStream(new CryptInputStream(in, cipher, getDataEncryptionKey()));
		File temp = File.createTempFile("dirsync", null);
		FileOutputStream fout = new FileOutputStream(temp);
		try
		{
			IOUtils.copy(in, fout);
			if (target.exists())
			{
				target.delete();
			}
			IOUtils.closeQuietly(fout);
			IOUtils.closeQuietly(in);
			FileUtils.moveFile(temp, target);
		}
		catch (IOException e)
		{
			fetchStream(in);
			throw e;
		}
		finally
		{
			IOUtils.closeQuietly(fout);
			IOUtils.closeQuietly(in);
		}
	}
	
	private void fetchStream(InputStream in)
	{
		/*
		 * HTTP-Streams muessen zu Ende gelesen werden
		 */
		byte[] buf = new byte[1024];
		try
		{
			while (in.read(buf) > 0)
			{
			}
		}
		catch (IOException e)
		{
			return;
		}
	}
	
	private void upload(InputStream in, String s3key, long length) throws S3ServiceException
	{
		S3Object so = new S3Object(s3key);
		so.setDataInputStream(in);
		so.setContentLength(length);
		s3Service.putObject(bucket, so);
	}
	
	private void deleteRemoteFile(String id) throws S3ServiceException
	{
		s3Service.deleteObject(bucket, getFileKey(id));
	}
	
	/**
	 * Generiert Liste mit geanderten Dateien. Fehlende oder neue Dateien werden auch hinzugefuegt.
	 * List wird unter berucksichtigung der 'exclude' und/oder 'include' Patterns erstellt
	 * 
	 * @param baseDir
	 * @param folder
	 * @return
	 * @throws IOException
	 */
	@SuppressWarnings("unchecked")
	private List<LocalFile> generateChangedFileList(File baseDir, Folder folder) throws Exception
	{
		HashSet<String> localFiles = new HashSet<String>();
		List<LocalFile> ret = new LinkedList<LocalFile>();
		for (File file : (Collection<File>) FileUtils.listFiles(baseDir, FileFilterUtils.trueFileFilter(),
			FileFilterUtils.trueFileFilter()))
		{
			if (!file.isFile())
			{
				/*
				 * Ordner ignorieren
				 */
				continue;
			}
			String filename = computeRelativeName(baseDir, file);
			if (!shouldIncludeFile(filename))
			{
				/*
				 * Laut exclude/include Regeln soll die Datei ignoriert werden
				 */
				continue;
			}
			localFiles.add(filename);
			
			FileInfo info = folder.getFileInfo(filename);
			if (info == null)
			{
				/*
				 * Datei ist neu und wurde noch nicht hochgeladen, bei synchronisierung abhaengig
				 * von der Richtung, 'up' oder 'down' wird sie entweder hochgeladen oder geloescht
				 */
				ret.add(new LocalFile(file, filename));
			}
			else
			{
				if (isFileChanged(file, info))
				{
					ret.add(new LocalFile(file, filename));
				}
			}
		}
		/*
		 * Jetzt evt entfernte Dateien hinzufuegen
		 */
		for (String filename : folder.getIndexData().keySet())
		{
			if (!shouldIncludeFile(filename))
			{
				/*
				 * Laut exclude/include Regeln soll die Datei ignoriert werden
				 */
				continue;
			}
			if (!localFiles.contains(filename))
			{
				/*
				 * Datei wurde lokal geloescht
				 */
				ret.add(new LocalFile(new File(baseDir, filename), filename));
			}
		}
		return ret;
	}
	
	/**
	 * Methode testet ob gegebene Dateiename laut vorhandenen exclude/include Regeln in Liste der zu
	 * bearbeitenden Datein eingeschlossen werden soll
	 * 
	 * @param filename
	 * @return
	 */
	private boolean shouldIncludeFile(String filename)
	{
		if (!includePatterns.isEmpty())
		{
			boolean include = false;
			/*
			 * Wenn Include-Regel nicht leer sind, dann jede Datei die nicht darunter faellt
			 * excludieren
			 */
			for (Pattern p : includePatterns)
			{
				if (p.matcher(filename).matches())
				{
					include = true;
					break;
				}
			}
			if (!include)
			{
				return false;
			}
			
		}
		for (Pattern p : excludePatterns)
		{
			if (p.matcher(filename).matches())
			{
				return false;
			}
		}
		return true;
	}
	
	/**
	 * Testet ob eine Datei sich geaendert hat.
	 * 
	 * @param file
	 * @param info
	 * @return
	 * @throws Exception
	 */
	private boolean isFileChanged(File file, FileInfo info) throws Exception
	{
		if (file.length() != info.getLength())
		{
			return true;
		}
		if (file.lastModified() != info.getLastModified())
		{
			return true;
		}
		return false;
	}
	
	private byte[] digestFile(File file, MessageDigest digest) throws IOException
	{
		DigestInputStream in = new DigestInputStream(new FileInputStream(file), digest);
		IOUtils.copy(in, new NullOutputStream());
		in.close();
		return in.getMessageDigest().digest();
	}
	
	/**
	 * Methode berechnet relative Pfad einer Datei bezueglich eines Basisordners
	 * 
	 * @param baseDir
	 * @param file
	 * @return
	 */
	private String computeRelativeName(File baseDir, File file)
	{
		URI relUri = baseDir.toURI().relativize(file.toURI());
		return relUri.getPath();
		// String ret = StringUtils.substringAfter(file.getAbsolutePath(),
		// baseDir.getAbsolutePath());
		// /*
		// * Jetzt Dateiname in UNIX Form bringen, falls Programm auf Windows
		// laeuft
		// */
		// ret = ret.replace('\\', '/');
		// return ret;
	}
	
	/**
	 * Liefert Folder OBjekt mit dem gegebenen Namen. Wenn Folder Objekt schon runtergeladen wurde,
	 * dann wird die lokale Version zuruckgegeben. Anderfalls wird zuerst Folder Objekt vom Server
	 * runtergeladen
	 * 
	 * @param name
	 * @return
	 * @throws DirSyncException
	 */
	public Folder getFolder(String name) throws DirSyncException
	{
		connect(false);
		String id = mainIndex.getFolders().get(name);
		if (id == null)
		{
			return null;
		}
		try
		{
			return getFolderIntern(id);
		}
		catch (Exception e)
		{
			throw new DirSyncException("Reading folder description failed", e);
		}
	}
	
	private Folder getFolderIntern(String id) throws IOException, S3ServiceException
	{
		Folder ret = folderCache.get(id);
		if (ret == null)
		{
			ret = downloadFolder(id);
			if (ret != null)
			{
				folderCache.put(ret.getName(), ret);
			}
		}
		return ret;
	}
	
	private Folder downloadFolder(String id) throws IOException, S3ServiceException
	{
		Folder folder = (Folder) downloadObject(getFolderKey(id), getDataEncryptionKey());
		folder.initFileHashIndex();
		return folder;
	}
	
	private void uploadObject(String s3key, byte[] encKey, Serializable obj) throws S3ServiceException
	{
		ByteArrayOutputStream bout = new ByteArrayOutputStream();
		try
		{
			ObjectOutputStream oout = new ObjectOutputStream(new DeflaterOutputStream(new CryptOutputStream(bout,
				cipher, encKey)));
			oout.writeObject(obj);
			oout.close();
		}
		catch (IOException e)
		{
			/*
			 * sollte eigentlich nie vorkommen
			 */
			throw new RuntimeException(e);
		}
		byte[] data = bout.toByteArray();
		upload(new ByteArrayInputStream(data), s3key, data.length);
		transferredData += data.length;
	}
	
	private Object downloadObject(String s3key, byte[] encKey) throws IOException
	{
		InputStream in = downloadData(s3key);
		if (in == null)
		{
			return null;
		}
		CountingInputStream cin = new CountingInputStream(new InflaterInputStream(new CryptInputStream(in, cipher,
			encKey)));
		ObjectInputStream oin = new ObjectInputStream(cin);
		try
		{
			Object o = oin.readObject();
			transferredData += cin.getByteCount();
			return o;
		}
		catch (ClassNotFoundException e)
		{
			/*
			 * sollte eigentlich nie vorkommen
			 */
			throw new IOException(e.getLocalizedMessage());
		}
		finally
		{
			IOUtils.closeQuietly(in);
		}
	}
	
	private InputStream downloadData(String key)
	{
		try
		{
			S3Object obj = null;
			obj = s3Service.getObject(new S3Bucket(bucket), key);
			if (obj == null || obj.getDataInputStream() == null)
			{
				return null;
			}
			return obj.getDataInputStream();
		}
		catch (S3ServiceException e)
		{
			/*
			 * Hoechstwahrscheinlich ist der Objekt nicht vorhanden
			 */
			// log.warn(e.getLocalizedMessage());
		}
		return null;
	}
	
	/**
	 * Methode loescht alle Objekte aus einem Bucket
	 * 
	 * @throws DirSyncException
	 */
	public void cleanBucket() throws DirSyncException
	{
		try
		{
			for (S3Object so : s3Service.listObjects(new S3Bucket(bucket)))
			{
				try
				{
					s3Service.deleteObject(bucket, so.getKey());
				}
				catch (S3ServiceException e)
				{
					log.error("Deleting of object '" + so.getKey() + "' failed: " + e.getLocalizedMessage());
				}
			}
		}
		catch (S3ServiceException e)
		{
			throw new DirSyncException("Cleaning bucket failed: " + e.getLocalizedMessage());
		}
	}
	
	/**
	 * Methode findet nicht mehr referenzierte Objekte in S3 und loescht sie
	 * 
	 * @throws DirSyncException
	 */
	public void cleanUp() throws DirSyncException
	{
		connect(false);
		/*
		 * Zuerst liste mit Objekten erstellen die referenziert sind
		 */
		Set<String> referencedKeys = getAllUsedObjects();
		int removedCount = 0;
		try
		{
			for (String key : S3Utils.listObjects(accessKey, secretKey, bucket))
			{
				if (!referencedKeys.remove(key))
				{
					removedCount++;
					s3Service.deleteObject(bucket, key);
				}
			}
		}
		catch (Exception e)
		{
			throw new DirSyncException(e);
		}
		
		log.info("Objects deleted: " + removedCount);
	}
	
	/**
	 * Methode liefrt Auflistung mit S3 Keys aller aktuell vom Programm benutzten und gespeicherten
	 * Objekte (Daten als auch Verwaltungsinformationen)
	 * 
	 * @return
	 * @throws DirSyncException
	 */
	private Set<String> getAllUsedObjects() throws DirSyncException
	{
		HashSet<String> referencedKeys = new HashSet<String>();
		/*
		 * MainIndex
		 */
		referencedKeys.add(getMainIndexKey());
		/*
		 * Folders
		 */
		for (String id : mainIndex.getFolders().values())
		{
			referencedKeys.add(getFolderKey(id));
		}
		/*
		 * Dateien aus Folders
		 */
		for (String name : mainIndex.getFolders().keySet())
		{
			Folder folder = getFolder(name);
			if (folder == null)
			{
				continue;
			}
			for (FileInfo info : folder.getIndexData().values())
			{
				referencedKeys.add(getFileKey(info.getStorageId()));
			}
		}
		return referencedKeys;
	}
	
	/**
	 * Methode gibt Zusammenfassung der auf dem Server liegenenden Daten
	 * 
	 * @throws DirSyncException
	 */
	public void printStorageSummary() throws DirSyncException
	{
		int storedObjects = 0;
		try
		{
			if (!S3Utils.bucketExists(accessKey, secretKey, bucket))
			{
				System.out.println("No such bucket");
				return;
			}
			System.out.println("Summary for bucket " + bucket);
			for (String str : S3Utils.listObjects(accessKey, secretKey, bucket))
			{
				storedObjects++;
			}
		}
		catch (Exception e)
		{
			throw new DirSyncException(e.getMessage());
		}
		connect(false);
		int usedObjects = getAllUsedObjects().size();
		System.out.println("Total objects in use: " + usedObjects);
		System.out.println("Total stored objects: " + storedObjects);
		if (usedObjects < storedObjects)
		{
			System.out.println((storedObjects - usedObjects)
				+ " orphaned objects found. '-cleanup' command recommended");
		}
		System.out.println("---------------------------------------------------------------------------------");
		long totalSize = 0;
		for (Map.Entry<String, String> entry : mainIndex.getFolders().entrySet())
		{
			System.out.println("Folder: " + entry.getKey());
			Folder folder = getFolder(entry.getKey());
			long folderSize = 0;
			if (folder != null)
			{
				for (FileInfo fi : folder.getIndexData().values())
				{
					folderSize += fi.getLength();
				}
				System.out.println("Files: " + folder.getIndexData().size());
				System.out.println("Size: " + FileUtils.byteCountToDisplaySize(folderSize));
				System.out.println();
			}
			totalSize += folderSize;
		}
		System.out.println("---------------------------------------------------------------------------------");
		System.out.println("Total folders: " + mainIndex.getFolders().size());
		System.out.println("Total size: " + FileUtils.byteCountToDisplaySize(totalSize));
	}
	
	/**
	 * Methode setzt Filterregel fuer Dateien die aus Prozess ausgeschlossen werden sollen. Exclude
	 * Regeln haben hoehere Prioritaet als Include Regel.
	 * 
	 * @param excludes
	 *        Filterregel mit * und ?
	 */
	public void setExcludePatterns(Collection<String> excludes)
	{
		excludePatterns = new ArrayList<Pattern>(excludes.size());
		for (String str : excludes)
		{
			String exp = RegExpUtil.convertSimpleRegexpToJava(str);
			excludePatterns.add(Pattern.compile(exp));
		}
	}
	
	/**
	 * Methode setzt Filterregel fuer Dateien die in das Prozess eingeschlossen werden sollen.
	 * Exclude Regeln haben hoehere Prioritaet als Include Regel.
	 * 
	 * @param includes
	 *        Filterregel mit * und ?
	 */
	public void setIncludePatterns(Collection<String> includes)
	{
		includePatterns = new ArrayList<Pattern>(includes.size());
		for (String str : includes)
		{
			String exp = RegExpUtil.convertSimpleRegexpToJava(str);
			includePatterns.add(Pattern.compile(exp));
		}
	}
	
	static private String getMainIndexKey()
	{
		return SYS_DATA_PREFIX + DELIMITER + MAIN_INDEX_KEY;
	}
	
	static private String getFolderKey(String id)
	{
		return SYS_DATA_PREFIX + DELIMITER + id;
	}
	
	static private String getFileKey(String id)
	{
		return FILES_PREFIX + DELIMITER + id;
	}
}
